package org.calzz.rpc;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.net.URLDecoder;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.calzz.jajb.JsonDeserializer;
import org.calzz.jajb.JsonSerializer;
import org.calzz.jajb.utils.ReflectionUtils;
import org.calzz.rpc.controller.Session;
import org.calzz.rpc.envelope.JsonInvocationError;
import org.calzz.rpc.envelope.JsonInvocationException;
import org.calzz.rpc.envelope.JsonRequest;
import org.calzz.rpc.envelope.JsonResponse;
import org.calzz.rpc.exporter.Internal;
import org.calzz.rpc.exporter.RemoteMethodInfo;
import org.calzz.rpc.exporter.RemoteParameterInfo;
import org.calzz.rpc.get.IUrlMappingStrategy;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.beans.factory.support.AbstractBeanDefinition;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.annotation.ClassPathBeanDefinitionScanner;
import org.springframework.context.annotation.ScannedGenericBeanDefinition;
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.core.MethodParameter;
import org.springframework.core.PrioritizedParameterNameDiscoverer;
import org.springframework.core.type.filter.AnnotationTypeFilter;
import org.springframework.stereotype.Controller;
import org.springframework.web.HttpRequestHandler;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

/**
 * The main class which is responsible for:
 * 1. scanning for registered components at the application start-up
 * 2. constructing remote method structure
 * 3. handling remote method invocations
 * 
 * @author Marek Brodziak <marek.brodziak@gmail.com>
 * @author Piotr Pejas <piotr.pejas@gmail.com>
 * @since Mar 6, 2008
 */
public class JsonServiceExporter implements HttpRequestHandler, ApplicationContextAware, BeanFactoryPostProcessor {
	private Log log = LogFactory.getLog(JsonServiceExporter.class);

	private final static String JSON_RPC_VERSION = "1.1";
	private final static String SYSTEM_CONTROLLERS_PACKAGE = "org.calzz.rpc.controller";
	private final static Pattern METHOD_PATTERN = Pattern.compile("[a-zA-Z_][a-zA-Z_0-9]*");
	private final static Pattern CONTROLLER_PATTERN = Pattern.compile("[a-zA-Z_][a-zA-Z_0-9\\.]*");
	private final static String UTF8_ENCODING = "UTF-8";
	private String serviceName;

	private JsonDeserializer jsonDeserializer;
	private JsonSerializer jsonSerializer;
	private ApplicationContext applicationContext;
	private Set<String> basePackageList = new HashSet<String>();
	private Map<String, Map<String, RemoteMethodInfo>> controllers = new HashMap<String, Map<String, RemoteMethodInfo>>();
	private IUrlMappingStrategy urlMappingStrategy;
	private boolean validateHttpHeaders = true;

	/*
	 * (non-Javadoc)
	 * @see org.springframework.web.HttpRequestHandler#handleRequest(javax.servlet.http.HttpServletRequest,
	 *      javax.servlet.http.HttpServletResponse)
	 */
	@Override
	public void handleRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		JsonResponse jsonResponse = new JsonResponse();
		jsonResponse.setVersion(JSON_RPC_VERSION);
		try {

			if (validateHttpHeaders) {
				if (request.getHeader("User-Agent") == null) {
					throw new JsonInvocationException(JsonInvocationError.SERVER_ERROR, "The 'User-Agent' http header MUST be specified");
				}
				if (request.getHeader("Accept") == null) {
					throw new JsonInvocationException(JsonInvocationError.SERVER_ERROR, "The 'Accept' http header MUST be specified");
				}
			}

			JsonRequest jsonRequest = null;
			try {
				jsonRequest = parseRequest(request);
				jsonResponse.setId(jsonRequest.getId());
			} catch (Exception e) {
				throw new JsonInvocationException(JsonInvocationError.PARSE_ERROR, "Malformed json object", e);
			}

			if (jsonRequest.getMethod() == null) {
				throw new JsonInvocationException(JsonInvocationError.CALL_MEMBER_OUT_OF_SEQUENCE, "Method name not supplied");
			}
			if (jsonRequest.getVersion() == null) {
				throw new JsonInvocationException(JsonInvocationError.CALL_MEMBER_OUT_OF_SEQUENCE, "JSON RPC version not supplied");
			}
			if (!JSON_RPC_VERSION.equals(jsonRequest.getVersion())) {
				throw new JsonInvocationException(JsonInvocationError.CALL_MEMBER_OUT_OF_SEQUENCE, "JSON RPC version not supported");
			}

			// Remote method handling
			Map<String, RemoteMethodInfo> methods = controllers.get(jsonRequest.getControllerName());
			if (methods == null) {
				throw new JsonInvocationException(JsonInvocationError.PROCEDURE_NOT_FOUND, "Requested controller was not found: " + jsonRequest.getControllerName());
			} else {
				RemoteMethodInfo remoteMethod = methods.get(jsonRequest.getMethodName());
				if (remoteMethod == null) {
					throw new JsonInvocationException(JsonInvocationError.PROCEDURE_NOT_FOUND, "Requested method was not found: " + jsonRequest.getMethodName());
				} else {
					// Parameters deserialization
					Object[] params = {};
					try {
						params = getParameterValues(remoteMethod, jsonRequest.getParams());
					} catch (Exception e) {
						throw new JsonInvocationException(JsonInvocationError.BAD_CALL, "Parameter parsing failed", e);
					}

					// Check required parameters
					for (int i = 0; i < remoteMethod.getParametersCount(); i++) {
						RemoteParameterInfo paramInfo = remoteMethod.getRemoteParameterInfo(i);
						if (HttpSession.class.equals(paramInfo.getClazz())) {
							params[i] = request.getSession();
							continue;
						}
						if (paramInfo.isSessionValue()) {
							params[i] = request.getSession().getAttribute(paramInfo.getName());
							continue;
						}
						if (paramInfo.isRequired() || paramInfo.getClazz().isPrimitive()) {
							if (i > params.length - 1 || params[i] == null) {
								throw new JsonInvocationException(JsonInvocationError.BAD_CALL, "Required parameter not supplied: " + (paramInfo.getName() == null ? paramInfo.getOrdinal() : ('"' + paramInfo.getName() + '"')));
							}
						}
					}

					// Invoke method
					Object controllerBean = applicationContext.getBean(jsonRequest.getControllerName());
					if (controllerBean == null) {
						throw new JsonInvocationException(JsonInvocationError.PROCEDURE_NOT_FOUND, "Requested controller was not found: " + jsonRequest.getControllerName());
					}
					try {
						Object result = remoteMethod.getMethod().invoke(controllerBean, params);
						jsonResponse.setResult(result);
					} catch (Exception e) {
						log.error("Controller method invocation error:", e);
						throw new JsonInvocationException(JsonInvocationError.SERVICE_ERROR, "Remote method invocation exception", e);
					}
				}
			}
		} catch (JsonInvocationException e) {
			log.info("Remote method invocation exception: ", e);
			jsonResponse.setError(e.getError());
		}

		response.getWriter().write(jsonSerializer.serialize(jsonResponse));
	}

	/**
	 * @param info
	 * @param json
	 * @return
	 * @throws JsonInvocationException
	 */
	private Object[] getParameterValues(RemoteMethodInfo info, Object paramsObject) {
		Object[] params = new Object[info.getParametersCount()];
		// No argument method
		if (params.length == 0 || paramsObject == null) {
			return params;
		}

		// Parameters array
		if (ReflectionUtils.isInstanceOf(paramsObject.getClass(), Collection.class)) {
			int index = 0;
			List<?> paramList = (List<?>) paramsObject;
			for (Object obj : paramList) {
				RemoteParameterInfo paramInfo = info.getRemoteParameterInfo(index);
				if (paramInfo != null) {
					params[index] = jsonDeserializer.process(obj, paramInfo.getClazz(), new MethodParameter(info.getMethod(), index));
				}
				index++;
			}
			return params;
		}

		// Named parameters
		if (ReflectionUtils.isInstanceOf(paramsObject.getClass(), Map.class)) {
			@SuppressWarnings("unchecked")
			Map<String, Object> paramsMap = (Map<String, Object>) paramsObject;
			for (int ordinal = 0; ordinal < params.length; ++ordinal) {
				RemoteParameterInfo parameterInfo = info.getRemoteParameterInfo(ordinal);
				// Try to retrieve parameter by name
				Object value = paramsMap.get(parameterInfo.getName());

				// If null try retrieve by ordinal
				if (value == null) {
					value = paramsMap.get(String.valueOf(ordinal));
					if (value == null) {
						continue;
					}
				}

				if (value.getClass().isArray()) {
					value = Arrays.asList(value);
				}

				// If required argument is a single value and retrieved value is
				// a table take first element
				if (true && !parameterInfo.getClazz().isArray() && !ReflectionUtils.isInstanceOf(parameterInfo.getClazz(), Collection.class) && ReflectionUtils.isInstanceOf(value.getClass(), Collection.class)) {
					value = ((Collection<?>) value).iterator().next();
				}
				params[ordinal] = jsonDeserializer.process(value, parameterInfo.getClazz(), new MethodParameter(info.getMethod(), ordinal));
			}
		}
		return params;
	}

	/*
	 * (non-Javadoc)
	 * @see org.springframework.beans.factory.config.BeanFactoryPostProcessor#postProcessBeanFactory(org.springframework.beans.factory.config.ConfigurableListableBeanFactory)
	 */
	@Override
	public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
		// Fetch controller list
		BeanDefinitionRegistry registry = (BeanDefinitionRegistry) beanFactory;
		ClassPathBeanDefinitionScanner scanner = new ClassPathBeanDefinitionScanner(registry);
		scanner.addIncludeFilter(new AnnotationTypeFilter(Controller.class));

		// Default system controllers
		basePackageList.add(SYSTEM_CONTROLLERS_PACKAGE);

		// Iterate over package list
		for (String basePackage : basePackageList) {
			// Iterate over list of class with @Controller annotation in package
			for (BeanDefinition definition : scanner.findCandidateComponents(basePackage)) {
				try {
					// Resolve remote controller name
					Class<?> clazz = Class.forName(definition.getBeanClassName());
					String annotationValue = clazz.getAnnotation(Controller.class) == null ? null : clazz.getAnnotation(Controller.class).value();
					String controllerName = null;
					// Default controller name - simple class name
					controllerName = clazz.getSimpleName();
					if (annotationValue != null && !"".equals(annotationValue)) {
						// Name provided by @Controller annotation
						if (validateControllerName(annotationValue)) {
							controllerName = annotationValue;
						} else {
							log.warn("Invalid controler name: " + annotationValue);
						}
					}

					// Register controller in spring context with controller
					// name as bean name
					if (definition instanceof ScannedGenericBeanDefinition) {
						((ScannedGenericBeanDefinition) definition).setAutowireMode(AbstractBeanDefinition.AUTOWIRE_AUTODETECT);
					} else {
						log.warn("Can not set autowired mode, controllers registred without autowiring.");
					}
					registry.registerBeanDefinition(controllerName, definition);

					Map<String, RemoteMethodInfo> methods = new HashMap<String, RemoteMethodInfo>();
					// Iterate over controller methods
					for (Method method : clazz.getMethods()) {
						// Method must be public not abstract
						if (true
							&& Modifier.isPublic(method.getModifiers())
							&& !Modifier.isAbstract(method.getModifiers())
							&& !Object.class.equals(method.getDeclaringClass())
							&& (method.getAnnotation(Internal.class) == null)
							&& (method.getAnnotation(Autowired.class) == null)) {
							RemoteMethodInfo remoteMethodInfo = new RemoteMethodInfo();
							remoteMethodInfo.setMethod(method);

							// Resolve remote method name
							RequestMapping requestMapping = method.getAnnotation(RequestMapping.class);
							boolean methodNameSelected = false;
							if (requestMapping != null) {
								for (String alias : requestMapping.value()) {
									if (validateMethodParameterName(alias)) {
										methods.put(alias, remoteMethodInfo);
										methodNameSelected = true;
									} else {
										log.warn("Invalid method name: " + alias);
									}
								}
							}
							if (requestMapping == null || !methodNameSelected) {
								methods.put(method.getName(), remoteMethodInfo);
							}

							PrioritizedParameterNameDiscoverer discoverer = new PrioritizedParameterNameDiscoverer();
							String[] parameterNames = new String[method.getParameterTypes().length];
							discoverer.addDiscoverer(new LocalVariableTableParameterNameDiscoverer());
							try {
								parameterNames = discoverer.getParameterNames(method);
							} catch (Exception e) {
								// Class not compiled in debug mode, can not
								// discover parameters name
								for (int i = 0; i < parameterNames.length; i++) {
									parameterNames[i] = String.valueOf(i);
								}
								log.warn("Can not resolve procedure parameters name");
							}
							int ordinal = 0;
							for (Class<?> parameterClass : method.getParameterTypes()) {
								RemoteParameterInfo remoteParameterInfo = new RemoteParameterInfo();
								remoteParameterInfo.setClazz(parameterClass);
								remoteParameterInfo.setOrdinal(ordinal);
								remoteParameterInfo.setName(parameterNames[ordinal]);

								for (Annotation annotationClass : method.getParameterAnnotations()[ordinal]) {
									// Session attributes
									if (annotationClass.annotationType().equals(Session.class)) {
										remoteParameterInfo.setSessionValue(true);
										remoteParameterInfo.setRequired(false);
										continue;
									}

									// Parameter name/requirements settings
									if (annotationClass.annotationType().equals(RequestParam.class)) {
										RequestParam requestParam = (RequestParam) annotationClass;
										remoteParameterInfo.setRequired(requestParam.required());
										if (!"".equals(requestParam.value())) {
											if (validateMethodParameterName(requestParam.value())) {
												remoteParameterInfo.setName(requestParam.value());
											} else {
												log.warn("Invalid parameter name: " + requestParam.value());
											}
										}
									}
								}
								remoteMethodInfo.putParameter(remoteParameterInfo.getName(), ordinal, remoteParameterInfo);
								++ordinal;
							}
						}
					}
					controllers.put(controllerName, methods);
				} catch (ClassNotFoundException ignore) {
				}
			}
		}
	}

	private static boolean validateMethodParameterName(String name) {
		return METHOD_PATTERN.matcher(name).matches();
	}

	private static boolean validateControllerName(String name) {
		return CONTROLLER_PATTERN.matcher(name).matches();
	}

	/**
	 * @param httpRequest
	 * @return
	 * @throws UnsupportedEncodingException
	 * @throws IOException
	 * @throws JsonInvocationException when Content-Type http header not
	 *             specified
	 */
	private JsonRequest parseRequest(HttpServletRequest httpRequest) throws UnsupportedEncodingException, IOException, JsonInvocationException {

		JsonRequest request = null;

		String encoding = httpRequest.getCharacterEncoding();
		if (encoding == null) {
			encoding = UTF8_ENCODING;
		}

		if ("POST".equals(httpRequest.getMethod())) {
			if (validateHttpHeaders) {
				// header validation
				if (httpRequest.getHeader("Content-Type") == null) {
					throw new JsonInvocationException(JsonInvocationError.SERVER_ERROR, "The 'Content-Type' http header MUST be specified");
				}
				try {
					if (httpRequest.getHeader("Content-Length") == null || Integer.parseInt(httpRequest.getHeader("Content-Length")) != httpRequest.getContentLength()) {
						throw new JsonInvocationException(JsonInvocationError.SERVER_ERROR, "The 'Content-Length' http header MUST be specified");
					}
				} catch (NumberFormatException nfe) {
					throw new JsonInvocationException(JsonInvocationError.SERVER_ERROR, "The 'Content-Length' http header MUST be specified");
				}
			}

			StringBuffer requestBuffer = new StringBuffer();
			String requestLine = null;
			while ((requestLine = httpRequest.getReader().readLine()) != null) {
				requestBuffer.append(URLDecoder.decode(requestLine, encoding));
			}

			request = jsonDeserializer.deserialize(requestBuffer.toString(), JsonRequest.class);

		} else if ("GET".equals(httpRequest.getMethod())) {
			// Read the request using a given url mapping strategy
			request = urlMappingStrategy.doMapping(httpRequest);
			request.setVersion(JSON_RPC_VERSION);
		}

		return request;
	}

	/**
	 * @param basePackage
	 */
	public void setBasePackageList(Set<String> basePackage) {
		this.basePackageList = basePackage;
	}

	/**
	 * @param jsonDeserializer
	 */
	public void setDeserializer(JsonDeserializer jsonDeserializer) {
		this.jsonDeserializer = jsonDeserializer;
	}

	/**
	 * @param jsonSerializer
	 */
	public void setSerializer(JsonSerializer jsonSerializer) {
		this.jsonSerializer = jsonSerializer;
	}

	/*
	 * (non-Javadoc)
	 * @see org.springframework.context.ApplicationContextAware#setApplicationContext(org.springframework.context.ApplicationContext)
	 */
	@Autowired
	public void setApplicationContext(ApplicationContext applicationContext) {
		this.applicationContext = applicationContext;
	}

	/**
	 * @return
	 */
	public String getServiceName() {
		return serviceName;
	}

	/**
	 * @param serviceName
	 */
	public void setServiceName(String serviceName) {
		this.serviceName = serviceName;
	}

	/**
	 * @param urlMappingStrategy
	 */
	@Autowired
	public void setUrlMappingStrategy(IUrlMappingStrategy urlMappingStrategy) {
		this.urlMappingStrategy = urlMappingStrategy;
	}

	public boolean isValidateHttpHeaders() {
		return validateHttpHeaders;
	}

	public void setValidateHttpHeaders(boolean validateHttpHeaders) {
		this.validateHttpHeaders = validateHttpHeaders;
	}

	public Map<String, Map<String, RemoteMethodInfo>> getControllers() {
		return controllers;
	}
}
